Skip to content

feat/positioning-redesign -> staging#128

Merged
holkexyz merged 606 commits into
stagingfrom
feat/positioning-redesign
Jun 2, 2026
Merged

feat/positioning-redesign -> staging#128
holkexyz merged 606 commits into
stagingfrom
feat/positioning-redesign

Conversation

@holkexyz
Copy link
Copy Markdown
Member

@holkexyz holkexyz commented Jun 2, 2026

Brings feat/positioning-redesign into staging.

Scale

What's included (high level)

Notes for review

  • Given the size and the revert history, confirm this is the intended way to re-introduce the positioning redesign onto staging (vs. a fresh revert-of-the-revert).
  • Not merged — left for review and CI.

🤖 Generated with Claude Code

holkexyz and others added 30 commits May 27, 2026 19:30
Adds `isContributorWeightAcceptable` — empty (the field is
optional) or a finite non-negative number. Uses `Number(v)` so a
trailing "10abc" is rejected; parseFloat would silently truncate
to 10. Decimals are explicitly allowed since the lexicon stores
weights as free-form strings and a value like "0.25" or "1.5" is
fine.

UX: invalid weight inputs paint the same red border as invalid
identity inputs, expose `aria-invalid`, and surface an inline
"Weight must be a number (decimals are fine, e.g. 1.5)." error
below the row. canSubmit and handleSubmit both gate on every
weight being valid. Input gets `inputMode="decimal"` so mobile
keyboards show the numeric pad.

The cert detail page's `buildWeightPercents` already silently
ignored non-numeric weights when computing the % column; this
patch makes that invariant visible at creation time so authors
can't save weights the renderer would skip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The thin blue box that appeared around the first paragraph when
clicking into the editor came from ProseMirror's default rule in
prosemirror-view/style/prosemirror.css:

  .ProseMirror-selectednode { outline: 2px solid #8cf; }

Clicking into an empty editor can put it in a NodeSelection on the
first paragraph, which then renders with the default outline. We
have explicit selected-state styling for images
(`.leaflet-doc__image--selected`) and embeds
(`.leaflet-doc__embed--selected`) via React NodeViews, so the
default outline is redundant for the blocks we actually want to
highlight. Scoping the override to `.leaflet-editor__surface` keeps
the rule out of the read-only renderer and other ProseMirror
surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…kill

Reinstates `.leaflet-editor__content:focus-within` so the editor
matches the title + short-description inputs on focus (idle
border becomes primary, plus a 2px overlay-weak ring). Was
removed in an earlier pass that over-corrected to fix the first-
line box; this brings the editor visually in line with its peers
again.

Doubles down on the first-line box suppression:
- `.ProseMirror-selectednode` override keeps `!important` so a
  later stylesheet load order can't override it on accident.
  prosemirror-view ships its style/prosemirror.css with
  `sideEffects: [...]`, so the default outline IS in the bundle.
- Adds `> *:focus` / `> *:focus-visible { outline: none }` on
  direct children of the surface as a belt-and-suspenders catch
  for any browser-default focus ring that some WebKit builds
  paint on individual blocks of a contenteditable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…down, nuclear first-line outline kill

Location modal:
- maxWidth bumped 620 → 1100 so the "Add location" modal matches
  the "view location" modal that the cert detail page opens. The
  author flow and the reader flow now share a frame size.
- Address-typing input stretches to fill the modal width via the
  existing `create-cert__field--full` modifier (was sitting at
  the browser's default ~150px input width).
- "Existing URI" tab → "My locations" with a dropdown. On mount
  the dialog listRecords the signed-in user's
  `app.certified.location` collection (limit 100, sorted by
  name). Selecting a row + Add pushes that strongRef directly —
  no URI-paste or extra round-trip needed. Empty state and
  load-error messages cover the edge cases.

Leaflet editor first-line border, take three:
- Cast a much wider net than the previous targeted overrides.
  Every descendant of `.leaflet-editor__surface` gets
  `outline: 0 !important; outline-style: none !important` across
  every state (idle / :focus / :focus-visible / selectednode).
  Direct-child blocks also get `box-shadow: none !important` so
  no inner ring can paint either.
- Selection cues for the blocks we actually want highlighted
  (images + iframe embeds) ride on their own
  `.leaflet-doc__image--selected` / `.leaflet-doc__embed--selected`
  classes painted by the React NodeViews, so wiping the default
  outline doesn't drop those affordances.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Native placeholders on `.cert-detail__title-input`,
  `.cert-detail__short-desc-input`, and `.cert-detail__meta-input`
  now use `var(--fg-muted)` — matching the Leaflet editor's own
  placeholder (which paints via a `::before` pseudo at the same
  token). The browser default (~rgba(0,0,0,0.42)) read as a
  cooler gray than the editor placeholder, so the description
  field looked like a different typography family. `opacity: 1`
  on the rule neutralises Firefox's default 0.54 multiplier.
- Date inputs (Time period) don't expose `::placeholder`; their
  "mm/dd/yyyy" format hint is painted via
  `::-webkit-datetime-edit`. Coloring that pseudo `--fg-muted`
  brings the Time-period placeholder into the same rhythm. Filled
  values inherit the muted color, which reads naturally in the
  aside meta column.
- Default the Rights dropdown to "Public Display of Contributions"
  once the curated list loads. Match by exact name; the dropdown
  still functions if the publisher renames the record (the user
  just has to pick manually). Only sets a default when no rights
  selection already exists, so re-renders don't clobber a manual
  pick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r modal

- My locations is now the first + default tab (was "New"); most
  authors are picking from a location they already published, not
  minting a fresh record on every cert.
- Each MyLocation now carries a parsed `coords` field
  (LatLng | null) derived from the record's `location` field via
  `parseLocationShape`. Polygons + smallBlob variants resolve to
  null and just won't pin; the strongRef is still attached on Add.
- The Existing tab gains a Leaflet map preview that drops a pin
  on the selected location's coords. The hint line under the map
  reads either the pinned coordinates, "no pinnable coordinates"
  for non-point variants, or a "Pick a location above" prompt
  when nothing is selected yet.
- Map height grows from a fixed 280px to a viewport-aware
  `Math.min(560, Math.max(320, innerHeight * 0.6))`. The taller
  map drives a taller modal — bringing the dialog visually in
  line with the cert detail "view location" modal that uses the
  same calc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…height

Leaflet editor:
- Adds `lastProducedJsonRef` — the JSON-stringified LinearDocument
  the editor most recently produced via its onUpdate. The
  controlled-state sync effect now compares the incoming `value`
  prop against this stamp and bails early when they match,
  recognising the parent's setState echo of our own update.
- The previous "compare against getJSON()" guard misfired when
  `tiptapToLinearDocument` → `linearDocumentToTipTap` wasn't
  byte-identical (e.g. empty paragraphs got `content: []`
  injected on the way back). That triggered `setContent` on every
  keystroke, which calls `tr.replaceWith` under the hood and
  resets the selection — most visibly when pressing Enter inside
  a list, where the cursor would jump over empty bullet items and
  land at the end of the next heading.
- Comparing the parent-passed LinearDocument against the one we
  produced (LinearDocument-vs-LinearDocument, not TipTap-vs-TipTap)
  sidesteps that whole class of round-trip mismatch.

Location modal:
- `mapHeight` now uses the exact calc the cert-detail "view
  location" modal uses — `Math.min(720, innerHeight * 0.7)` —
  so the add-location and view-location dialogs have identical
  body heights, not just identical widths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…itle

Location picker:
- Selecting from the My-locations dropdown now jumps + zooms in
  to that record. Leaflet only honours `MapContainer` initial
  center/zoom on mount, and our shared MapDataEffect intentionally
  skips re-centering when a single pin moves (avoids a lurch on
  every click-to-pin in the New tab). Re-mounting the `<Map>` via
  a `key` prop that includes the selected URI is the lightest way
  to get jump-and-zoom only for the Existing-tab flow. Pin zoom
  is 13 in Existing mode, 6 in New mode.
- New tab now leads with a hint line: "Type a place to search,
  or click anywhere on the map to drop a pin." Mirrors the
  affordance copy used elsewhere in the app for combobox + map
  pickers.
- If the user picks coords in the New tab that already match a
  location in My locations (within 11m / 4 decimal places), the
  submit short-circuits to the existing strongRef instead of
  minting a duplicate. A `create-cert__loc-reused` banner reads
  "Already in My locations — using your existing record: X"
  for ~1.4s before the dialog closes.

Cert form:
- `.create-cert .cert-detail__title-input` drops from 1.875rem
  to 1.375rem. The full-size detail-page edit treatment is kept
  intact; only the create form scopes down so the title doesn't
  visually overpower the rest of the inputs sitting next to it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
One step past the full-world view. The empty-state map opened so
zoomed-out that the user couldn't tell which continent they were
looking at; zoom 2 puts the planet at a more readable scale
without forcing a region commitment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The New tab now has two text fields with distinct roles:

- "Search a city or address" sits above the map and drives the
  Nominatim typeahead. Picking a suggestion (or clicking the map)
  resolves into a pin and clears the search box so the user can
  see the search has settled.
- "Name" sits below the map and is the value the
  `app.certified.location` record is saved with. It auto-fills
  from the picked/reverse-geocoded display name but the user can
  freely overwrite it with something more specific ("Main office"
  instead of "123 Main St, San Francisco, CA, United States").
  Editing the Name field does NOT re-fire a search, which fixes
  the previous coupling where any rename pulled the user back
  into the search dropdown.

The lastSourceRef tracking that gated the search effect against
map-originated changes is gone — splitting the inputs makes it
unnecessary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New `create-cert__loc-fields` grid wrapper places the Search and
Name inputs as two equal columns above the map (collapses to a
stack below 560px). The Name field's standalone label + section
below the map is gone — the inline placeholder "Name (you can
rename it)" carries the intent in less vertical space.

Combobox gets `min-width: 0` so the grid column doesn't blow out
when the suggestion list contains long display names.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Uses the existing `usePageTitle` hook (lib/navbar-context.tsx)
so /create shows "New cert" in the navbar title slot. Mount/
unmount lifecycle is handled inside the hook — title clears on
navigation away.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ed focus, ISO dates

LeafletEditor:
- /create now passes `onImageUpload={(file) => uploadBlob(file)}`
  so the Insert image button renders and uploads work. `did` alone
  wasn't enough — `canUploadImages` requires both props.
- Toolbar buttons for link / image / embed no longer call
  `editor.commands.focus()` after the click. The post-click focus
  was racing the dialog's autofocus (the native showModal +
  requestAnimationFrame targets the URL input, then editor.focus
  yanks focus back to the surface) which is why opening the link
  and embed dialogs felt broken. New `keepFocusOff` flag on the
  internal `btn` helper turns the focus call off for the three
  dialog-opening / file-picker buttons; the mark + heading
  toggles keep the focus call so the caret returns to where the
  user expects after a toggle.

Date format → ISO 8601:
- `formatShortDate` now emits `YYYY-MM-DD` directly (hand-built
  from UTC parts so locale doesn't flip en-US output to
  MM/DD/YYYY).
- `formatMonthYear` returns `YYYY-MM`.
- Both are unambiguous internationally and sort as plain strings.
  Cascades through every surface that uses the helpers — cert
  headline byline, feed time periods, endorsements list, context
  updates, project detail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Modal maxWidth and mapHeight are now derived from the live
viewport instead of fixed values. Width caps at 1100 (sharing the
hero width with the view-location modal) but clamps to
viewport - 40 so there's always a 20px gutter on each side; a 320
floor keeps a usable form on ultra-narrow phones (content
scrolls horizontally inside the dialog in that edge case). Map
height = viewport - 40 - 280px (the non-map chrome the dialog
also carries) capped at 720 and floored at 220 so the map doesn't
shrink to a sliver on short windows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rm nesting

LeafletEditor on /create sits inside the page's publish <form>.
The LinkDialog and EmbedDialog each wrap their submit in their
own <form onSubmit={...}>, which puts an inner form inside the
outer form — invalid HTML that browsers handle inconsistently.
The most visible symptom: the inner form's submit handler doesn't
fire reliably, so clicking "Add link" or "Embed" in those dialogs
did nothing.

createPortal each dialog into document.body so they live as
siblings of the page root rather than children of the publish
form. The image button's native file picker isn't affected — it
doesn't render a form — which matches the user's observation that
images work but link + video don't.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Swaps the two <input type="date"> fields for <input type="text">
with `pattern="\d{4}-\d{2}-\d{2}"` and a "YYYY-MM-DD"
placeholder. The native date picker's visual format is
locale-dependent (MM/DD/YYYY in en-US, dd/mm/yyyy in en-GB, etc.)
which contradicted the rest of the app's ISO formatting. The
submit handler already consumes YYYY-MM-DD strings, so no other
changes were needed.

`inputMode="numeric"` keeps the mobile keyboard sensible;
`maxLength={10}` caps at the ISO length; aria-label spells out
the expected format for screen readers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the previous two-input (Search / Name) split with a
single field that switches between "search" and "edit" modes:

- Search mode (default, also after the user clears the field):
  typing fires the Nominatim typeahead. Suggestions dropdown
  appears beneath. Picking a suggestion (or clicking the map)
  flips into edit mode.
- Edit mode: typing renames the saved value without re-firing a
  search. Placeholder shifts to "Rename to something more
  specific". No dropdown.

The placeholder explanation above the field calls out the
rename affordance directly so first-time authors know they can
turn "123 Main St, San Francisco, CA, United States" into
"Main office". Emptying the field flips back to search so the
user can find a different place without an explicit "search
again" button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Hint above the field drops the "(e.g. Main office instead of the
  full address)" parenthetical. The shorter copy stays clear about
  the rename affordance without padding the dialog.
- New `<Search>` icon button anchored to the right of the input in
  edit mode. Clicking flips the field back to search mode using
  the current value as the query — no need to clear the rename
  first. `onMouseDown` (not onClick) so the input's focus-blur
  doesn't close the suggestions before the handler fires.
- Enter now swallows its default (the dialog is inside the
  publish form on /create; an unprevented Enter would submit the
  cert mid-edit). Behaviour:
    - search mode with open dropdown: pick the highlighted /
      first suggestion (same as before).
    - search mode with no dropdown / edit mode: re-enter search
      using the current value, mirroring the icon button.
- New CSS rule grows the input's right padding via
  `:has(.create-cert__loc-search-again)` so the typed value never
  overlaps the icon.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reverts the previous switch to <input type="text" pattern="..."> —
that dropped the calendar picker UI users expect on date fields.

The native <input type="date"> .value is ALWAYS YYYY-MM-DD
regardless of the locale-dependent visual format in the field
(en-US: MM/DD/YYYY, en-GB: DD/MM/YYYY, etc.), so the submit
handler still consumes ISO 8601 strings either way. Only the
displayed text formatting is locale-controlled and can't be
overridden without giving up the picker.

The cascade-everywhere ISO formatting (formatShortDate /
formatMonthYear emit YYYY-MM-DD / YYYY-MM) is untouched — those
control how dates are RENDERED in the read-only cert detail
surfaces, where there's no input element involved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a single-icon create button to the right cluster of the
desktop top-bar (left of the global search field, before Apps and
Settings). Clicking opens a portalled dropdown with three
shortcuts:
- New cert      → /create
- New project   → /project/new
- New group     → /groups/create

The trigger and panel follow the existing account-switcher
pattern verbatim — anchor recomputed on resize/scroll, close on
outside click, Escape, and route change. Trigger renders only
when the user is authenticated (the three target routes all
require auth).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-requested placement. The button + portal panel logic is
unchanged; only the JSX order shifts so the button sits between
the search field and the Apps icon.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…utor field

Groups can now publish certs from the same /create form their
personal flow uses. Previously the page hard-blocked when
`activeOrg` was set with a "Switch to your personal account"
message — the underlying reason was that the xrpc proxy validates
`repo === session DID`, so writes to a group's repo had to go
through a different path.

- `/api/groups/[groupDid]/activity` PUT route now accepts an
  optional rkey. rkey present → putRecord (existing update path).
  rkey absent → createRecord on the group's repo via
  `app.certified.group.repo.createRecord`. Mirrors the pattern the
  sibling location route already uses.
- /create's submit picks between the xrpc proxy and the group BFF
  based on `activeOrg`. The image-blob upload, the
  LocationPickerDialog's putLocationRecord, and the LeafletEditor
  image upload all thread the same target DID so every blob /
  location / cert lands on the right repo.
- The "Switch to your personal account" early-return is removed.

Also extracted ContributorIdentityField + its helpers
(`normalizeIdentity`, `isContributorIdentityAcceptable`,
`isContributorWeightAcceptable`) into
`src/components/create/contributor-identity-field.tsx` so the
upcoming /project/new form can share them. /create now imports
from that module.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the "Project editor — coming soon" EmptyState at
/project/new with a working form that mirrors /create's
structure and reuses its building blocks:

Layout (parallels the project-detail overview):
- Full-width banner image at the top (16:9 narrow → 21:9 wide
  via the existing `.project-detail__hero` rules), with the
  same `ImageEditOverlay` (with-remove variant) the cert image
  slot uses.
- Title input (in `.cert-detail__headline` shell) with a
  min/max grapheme counter under it.
- Short description textarea with the same counter pattern.
- `.project-detail__meta` strip carrying Time period (two date
  inputs), Location (single string — the project lexicon stores
  it as a plain string, not a strongRef array), and the long
  description (LeafletEditor) on a full-width row.
- Contributors section reusing the cert-create row layout:
  identity (typeahead) → role → weight → remove. Validation,
  duplicate detection, "Add me" shortcut, and the per-row
  inline error variants all behave identically.

Form gates (mirroring /create):
- Title min 5 / max 800, short description min 100 / max 300
  (lexicon caps), all contributor identities must be a DID or
  handle, weights numeric, no duplicate contributors.
- canSubmit + handleSubmit both enforce; the publish button
  stays disabled until everything passes.

Wire format:
- POST `com.atproto.repo.createRecord` to the user's own repo
  with `{ $type: "org.hypercerts.collection", type: "project",
  title, shortDescription, createdAt, description?, banner?,
  startDate?, endDate?, location?, contributors?, items: [] }`.
  Empty `items` lets the project detail page's cert picker take
  over post-create.
- Group-owned project creation is deferred (the existing
  group-project BFF route is update-only); the form currently
  writes to the signed-in user's repo when an org is active.
- After publish, redirect to `/project/<did>/<rkey>`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- The form was sitting in the `.page-layout` 296+1fr grid, which
  reserved an empty 296px aside column and stretched the content
  to the page-layout's main track. Switched to the same wrapper
  the project detail page uses — outer `.project-detail-page` +
  `.project-detail--wide` (the latter triggers `.app-shell__
  content` to widen to 1100px via the existing `:has()` rule) and
  inner `.project-detail` (which caps at 960px on desktop, 720px
  narrow, centered with 16px gutters). Form content width now
  matches the project detail overview exactly.
- DesktopTopBar's project-detail tabs row (Description / Certs /
  Updates) was firing on `/project/new` because the predicate was
  a `pathname.startsWith("/project/")` check. Excluded
  `/project/new` so the create form doesn't carry the tabs row of
  an existing project record.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… offset

Two related fixes for the broken settings layout:

1. OrgSettings (the panel shown when the viewer is acting as a
   group) wrapped its content in `<div className="sx__layout">`,
   but `.sx__layout` was removed when the personal settings panel
   migrated to the shared `.page-layout` grid. Without a grid parent
   the aside (which still carries `position: sticky` + full width)
   ended up block-level and visually overlaid the panel during
   scroll. Switch the wrapper to the same `.page-layout` /
   `.page-layout__main` chrome the personal panel uses, so both
   settings entry points share one layout.

   Also drop `sx--wide` — it had no CSS, only meaning back when the
   `:has(.sx)` width override didn't exist.

2. Sticky offset on `.sx__menu` was `row1 + row2 + 16px = 112px`,
   but the settings page renders row 1 only. The 44px row-2 padding
   left a visible gap between the navbar's bottom border and the
   pinned nav. Switch to `--top-bar-total + 16px`, which resolves to
   row 1 only on pages without a tabs row and row 1 + row 2 on
   pages with one. Same change applied to the rail's `max-height`
   cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…picker; align icons

Collection-lexicon corrections:
- Drops contributors[] and time-period (startDate/endDate) — those
  fields exist on `org.hypercerts.claim.activity`, NOT on
  `org.hypercerts.collection`. The earlier draft had carried them
  over from the cert form.
- title.maxGraphemes is 80 (not 800); the title counter now caps
  at 80.
- shortDescription is optional in the lexicon — dropped the min,
  kept the maxGraphemes 300 ceiling.
- Required fields are now just title + createdAt, matching the
  spec.

Cert picker added directly on the create form:
- "Add cert" button reveals the shared `CertSearch` typeahead
  (same component the project detail edit mode uses). Selecting a
  cert pushes a strongRef onto a local `items` state; the row is
  rendered above the picker with a remove button. On submit the
  array is attached as `items[]` on the record.
- The picker prioritises the user's own certs and excludes URIs
  already added so duplicates can't slip through.

Icons + headings:
- `.project-detail__meta-label` now `display: inline-flex` with a
  5px gap so the small leading icons sit on the same baseline as
  the uppercase label text, matching the cert-detail meta-label
  rhythm.

Location is still deferred to a follow-up — the cert-create
LocationPickerDialog needs to be extracted into a shared
component before /project/new can render a single-location
variant of it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… single location picker

- Moves the LocationPickerDialog (and its `AddedLocation` shape)
  out of `src/app/create/page.tsx` and into
  `src/components/create/location-picker-dialog.tsx` as a
  default-exported component. The dialog itself is unchanged —
  same two tabs (My locations / New), same Nominatim flow, same
  return shape — only its home moved.
- /create imports from the new module; the inline definition is
  gone, freeing about 670 lines from the cert form.
- /project/new now renders a "Location" section between
  Description and Certs. The "Add location" button opens the same
  dialog; the host stores ONE picked entry (the collection lexicon
  carries a single `location` strongRef, not an array). The chip
  below the button shows the resolved name with a remove button;
  "Add location" flips to "Change location" once one is set.
- Drops the "— optional" hint from the project's short-description
  placeholder; the counter without a min already signals it's
  optional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Once listRecords confirms zero published locations, drop the tab
strip entirely and flip the dialog straight into the New flow.
Single-option tab bars read as visual noise — first-time users
land on the actually-useful surface instead of a tab that points
at an empty state. The tab stays visible during the initial fetch
so the dialog doesn't flicker between layouts on first open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Above the CertSearch typeahead, /project/new now renders a
click-to-add checklist of the author's own certs. On mount it
listRecords org.hypercerts.claim.activity against the active
repo (the user's own DID or the group they've switched into),
sorts newest-first, and renders each row as a checkbox label.
Toggling a row adds/removes the strongRef from the project's
items[] array. Added rows fade to 0.6 opacity so the eye lands
on the still-unattached certs.

The CertSearch typeahead is still there below — relabelled
"Search for any cert" — for finding certs that aren't the
author's own. Both surfaces feed the same items[] state; the
existing exclude-by-URI keeps duplicates from sneaking in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a  override
that caps the shell at 1008px on desktop instead of the cert
detail page's 1280px. 1008 = 960 (matching .project-detail's
single-pane width) + 48 (.page-layout's 24px horizontal padding
each side). The 296px aside stays as-is; the only column that
narrows is the flex main pane, from ~904px down to ~632px.
Cert detail (view + inline edit) is untouched because it doesn't
carry .create-cert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
holkexyz and others added 22 commits June 2, 2026 12:02
Interactive mockups and a single decision document for how a person acts
for the groups they own/admin: the seven-pattern design space, the three
candidates (Options 1/2 act-as refinements, Option 3 GitHub model), the
"an org is a full account, not a namespace" insight, and the recommendation
(act-as framed as delegation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lass actors

A person who owns or admins a group can now operate that group as a
first-class atproto actor: no separate login, and no sticky mode that hides
who is acting. Identity is always legible and writes are attributed correctly.

Backend (group BFF routes):
- POST/DELETE /api/groups/[did]/endorse — create/delete app.certified.badge.award
  on the group repo.
- POST /api/groups/[did]/endorsement-definition — mint the group's endorsement
  definition on its repo.
- DELETE added to /api/groups/[did]/follow (group unfollow; create already existed).

Lib (src/lib/atproto):
- createEndorsementAward / deleteEndorsementAward / deleteFollow gain an optional
  { targetDid } and route through the group BFF when delegating;
  ensureGroupEndorsementDefinition resolves/creates the group's definition via a
  federated read. All personal paths are byte-for-byte unchanged.

Frontend:
- ActingAsBar: persistent delegation indicator on every screen,
  "Operating <Group> as @<you> (<role>)" with an inline Switch to personal.
- Acting-as persistence moved from localStorage to sessionStorage (session-scoped,
  so a delegation can't silently outlive the session).
- Profile Follow / Endorse / unfollow (sidebar, followers grid, and the
  Endorsements tab give + revoke) act AS the group when delegating; the endorse
  reason modal names the endorser, the operator, and the subject. The earlier
  personal-only guard is replaced by real group routing.

Out of scope (follow-ups): respond-as-group to received endorsements
(badge.response), the personal /endorsements page + endorsement lists stay
personal, personal-only nav-gating unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ceive

Completes the org endorse-actor story: an admin acting as a group can now
accept / reject (and reset) endorsements the GROUP receives, writing the
app.certified.badge.response to the group's repo instead of the personal one.

- New POST/DELETE /api/groups/[did]/response (group badge.response), mirroring
  the endorse route.
- createResponse / deleteResponse / deleteAllResponsesForAward gain an optional
  { targetDid } and route through the group BFF when delegating; personal paths
  unchanged.
- useOwnResponseStates takes an optional responder DID so the Received tab
  reflects the GROUP's accept/reject state when acting as it.
- ResponseButtons / ResponseMenu accept a targetDid; the profile Endorsements
  Received tab passes it when acting as the group. The give and respond owner-
  side gates are unified under a single canManage = owner || acting-as-this-group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(org): act-as delegation — orgs can endorse and follow as first-class actors
On your own profile's Groups tab, each group row gains an "Operate as"
button (delegate into the group), deliberately separate from the row link
which goes to the group's profile. The active group shows a disabled
"Operating" state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The endorse delegation modal said "acting as an admin" regardless of the
operator's actual role. operatorRole is now threaded from activeOrg.role
(owner/admin/member). The ActingAsBar already used the variable role; the
modal was the only hard-coded instance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
While delegated you could still give/revoke/accept/reject endorsements on
your PERSONAL account, which writes to your personal repo while the chrome
says you're the org — confusing and wrong. Personal management now requires
!activeOrg; group management requires acting AS that group. Audited and
fixed every instance of the pattern:
- profile Endorsements tab: canManage = (owner && !activeOrg) || acting-as-this-group; personal endorsement lists hidden while delegated
- /notifications: accept/reject controls hidden while delegated
- /endorsements page: redirects to /home while delegated

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The typed-list paste flow required an at:// URI and rejected a bare DID
("Not an at:// URI"). For an accounts list you can now paste just
did:plc:… (with or without at://) and it expands to the conventional
at://<did>/app.certified.actor.profile/self item. Help text + placeholder
updated to show the DID form. Cert / project lists are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(org): delegation follow-ups — operate-as button, real role, no personal actions while delegated
feat(lists): accept a bare DID when adding accounts to a list
…items

Several highlighted ("active") items rendered white text on a near-white
fill in dark mode. Two root causes, both fixed by using theme-aware tokens:

1. background: var(--fg-primary) + a FIXED white text (#ffffff, or
   var(--bg-primary, #fff) where --bg-primary is an undefined token that
   silently resolves to #fff). --fg-primary flips to near-white in dark
   mode, so the fill and the text were both light. Fixed: text uses
   var(--bg-canvas), which inverts with the fill (light in light mode,
   dark in dark mode), matching the existing .onboarding-modal__step--current.
   - .explore__filter--active, .explore__view-btn--active (explore.css)
   - .sx-menu__item--active + its icon (settings-page.css)
   - workspace active items x5 (workspace.css)

2. background: rgba(255,255,255,.92) (a fixed near-white image-overlay pill)
   + color: var(--fg-primary), which flips near-white in dark mode. Fixed:
   text uses the invariant var(--color-primary) since the surface is invariant.
   - .image-edit-overlay__btn--ghost (components.css)
   - .profile-banner-upload__btn--ghost (profile-inline-edit.css)

Verified via computed styles in dark mode: each is now near-black text on
its light fill. Build compiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(dark-mode): white-on-white text on highlighted/active items
Removes the 'Record' (raw schema field/value table) tab from the cert
detail page:
- drop it from CERT_DETAIL_TABS (the tab button), the only definition
- remove 'record' from activity-detail's activeTab union + ?tab parsing
  (a stale ?tab=record link now falls back to Overview) and delete the
  record panel branch
- remove the now-orphaned .cert-detail__record* CSS

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
chore(cert): remove the Record tab from the cert overview
The /welcome footer was wrapped in .landing-section__inner (max-width
1536px, centered) with an extra 32px on .welcome-footer, so its
off-white band and border-top stopped short of the viewport edges.
Remove the cap and wrapper padding so the footer spans the full
viewport like SiteFooter does on every other page; SiteFooter keeps
its own 32px content inset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Renames the product noun cert/certs/certificate -> activity/activities in
user-visible strings only: page titles, section headings, tab labels,
empty states, aria-labels, placeholders, toast/error copy, and metadata.

Preserved exactly: the "Certified" brand, app.certified.* / org.hypercerts.*
lexicon NSIDs, certs.social, CGS; and all code identifiers, type/enum/wire
values (kind, list:certs, === "certs"), DOM ids, className strings, and
filenames. URL/query values are handled in the next commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Renames the user-visible URL value "certs" -> "activities" across the three
?tab/?kind surfaces, with legacy-alias parsing so old links still resolve:
- explore  ?kind=certs -> ?kind=activities (ExploreKind, parseKind, SUB_OPTIONS
  key, and the desktop tab strip's active-kind read)
- profile  ?tab=certs  -> ?tab=activities (TabKey, parser, tabpanel/tab DOM ids)
- project  ?tab=certs  -> ?tab=activities (its self-contained tab union + parser)
Plus every link that builds those URLs (home, profile-overview, workspace, project).

Kept (not URL values): record/wire discriminators (list:certs, cert.create),
WorkspaceLexicon "certs", FeaturedKind/MA_EARTH_COLLECTIONS.certs (static
internal lookups), the recently-viewed localStorage kind, and all identifiers,
CSS classes, and filenames.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…vity

refactor(naming): rename cert → activity (display text + URL values)
Author bylines (/explore) and the activity-detail contributor list each
resolved one DID per row via GET /api/resolve-did on mount. That route is
capped at 60 requests/min per IP, so a page with many distinct authors
plus a contributor-heavy activity blew the window and returned 429 — the
"author/contributors not loaded" + "Failed to load resource: 429" report.
Two amplifiers: the byline has no denormalized fallback (always resolves
over the network), and the hooks deleted-but-never-recovered failed
entries, so a 429'd avatar stayed broken until reload.

Collapse N requests into one, mirroring how the home feed already avoids
N lookups by denormalizing inline:

- Extract the per-DID resolution out of the GET route into a shared
  resolve-core.ts (GET behaviour byte-identical; its 16 tests still pass).
- Add POST /api/resolve-dids: resolves up to 50 identities (DID or handle)
  in one request with bounded server concurrency — one rate-limit hit per
  page instead of per row.
- Add a DataLoader-style client coalescer (resolve-did-batch.ts): loads in
  a render pass batch into one POST; dedup + bounded cache; on 429 it
  degrades to a DID fallback and negative-caches with a TTL (no throw, no
  retry storm, self-heals on the next view).
- Rewire useAuthorInfo / useContributorInfo / useAuthorNamesMap onto the
  coalescer; public APIs unchanged.

A page needing K authors now issues ceil(K/50) requests, not K.

Tests: coalescer (batch/dedup/429-degrade/TTL-requery/chunking) + batch
route (keying/handle-resolution/cap/dedup/limiter). 533/533 pass; tsc
clean; build compiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Round-1 implementation review (docs/resolve-did-batch/review-round-1.md):

- Weight the batch route's rate limiter by identity count (cost =
  identities.length) against a 600-identity/min budget, instead of a flat
  60-request budget that let one IP drive ~9k upstream fetches/min through
  the 50x fan-out. Adds a backward-compatible `cost` param to
  checkHttpRateLimit (incrby only when cost>1; TTL gate generalised to
  count===cost); every existing cost-1 caller is byte-identical. Side
  benefit: lifts anonymous users off the GET route's shared "anon" 60/min
  bucket that itself contributed to the 429s.
- Coalescer: honor the 429 cooldown inside flush() (a flush armed before a
  concurrent chunk's 429 no longer fires early), and track negative-eviction
  timers in a Map so they're replaced on reschedule and cleared on reset
  (no timer leak / no fake-timer bleed across tests).
- Tests: cooldown-defers-next-batch, mid-flush re-entrancy, incrby-weighted
  429; batch mocks updated for incrby.

Declined (rationale in review doc): useAuthorNamesMap self-heal (would cause
a per-render setTick loop), dead error plumbing in useAuthorInfo (harmless
defensive code in the public type), concurrent chunk flush (sequential is
intentionally gentle on the limiter).

536/536 tests; tsc clean; build compiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(resolve-did): batch author/contributor resolution to stop 429s
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 2, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 42f511fe-55ec-459d-b70f-312ab55ee016

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/positioning-redesign

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Jun 2, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
certified-app Ready Ready Preview, Comment Jun 2, 2026 8:06pm

Request Review

`npm test` (vitest 4.1.6 -> rolldown 1.0.1) imports `styleText` from
`node:util`, which only exists from Node 20.12.0. CI, .nvmrc, and the
engines floor were pinned to 20.9.0, so `vitest run` crashed at startup
with "does not provide an export named 'styleText'". Raise CI + .nvmrc to
20.19.0 (latest 20.x LTS) and the engines floor to >=20.12.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@holkexyz holkexyz merged commit 53fc822 into staging Jun 2, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant